41 — GPGPU 流场粒子 Shader

Three.js Journey · Bruno Simon · 难度:简单

41 — GPGPU 流场粒子 Shader 课程海报

介绍

有一个经常有人问我、也相当困难的技术,就是你在 Chartogne Taillet 网站上能看到的那种粒子场:

lesson demo video preview

粒子似乎在移动,就好像它们受到各种推动它们的流的影响。实际上这里实现了两种技术:

我们会解释这两种技术,并应用它们来给下面这个模型做动画 https://sketchfab.com/3d-models/mv-spartan-e2c3ced464f14e3b864f15871bf6d87d

lesson demo video preview

设置

起始代码和上一节课非常相似,粒子已经准备好被增强了:

关于粒子 shader:

理论

GPGPU 和流场,区别是什么?

GPGPU 代表 General-Purpose computing on Graphics Processing Units(图形处理单元上的通用计算)。它是一种用 GPU 处理数据、而不是为终端用户渲染像素的方式。当你需要把同一个复杂计算做上几千次时,它非常合适。

流场对应空间化的流。对于空间里的任意一点,我们计算一个方向:

现在想象把一个粒子扔进流场。每一帧,我们会计算该粒子处的流方向,并让它相应地移动,结果就是粒子四处移动:

不幸的是,流场会占用相当多的计算能力。因此,仅用 CPU 为成千上万个粒子逐帧做计算是不可能的,这就是为什么我们要用前面提到的 GPGPU。

持久性

这个问题的一个重要方面是数据需要持久化。到目前为止,做粒子动画时,我们一直在计算所有粒子应该在哪里的一种快照。

在流场的情况里,粒子的轨迹不可预测,并且随着时间不断演化。我们需要保存所有粒子的当前位置,以便在下一帧使用。

我们不能把结果保存在 attribute 里,因为如果在太多粒子上使用,更新会导致灾难性的帧率。

而这就是 GPGPU 完美解决我们问题的地方。如前所述,GPGPU 帮助我们使用 GPU 处理数据。要做到这一点,它利用了我们所说的 FBO(Frame Buffer Object,帧缓冲对象)。

FBO 是我们在其中保存渲染结果、而不是渲染到 <canvas> 的纹理,在 Three.js 里可以用 WebGLRenderTarget 创建它们。

在粒子的情况里,我们用纹理的每个像素代表一个粒子,其中 RGB 通道对应 XYZ 坐标。而 A 通道如果需要,可以用来做别的事(我们接下来会需要):

我们不在粒子的 vertex shader 里用经典的 position attribute,而是用 GPGPU 生成并更新的 FBO:

每一帧,我们根据上一个 FBO 来更新 FBO。这样,粒子的位置就持久化了,我们不断更新它们。

创建我们自己的 GPGPU

虽然它可能看起来非常有趣,但创建我们自己的 GPGPU 相当棘手。我们会用一个能省下大量时间和头疼的方案。不过,理解这个技术对于正确使用它、从而达到尽可能好的结果很重要。

如果我们自己做,大概会是这样。

首先,我们需要创建一个全新的离屏场景。这个场景独立于用户看到的实际场景。

在它里面,我们放一个相机,面向一个完美填满相机视野的平面。

我们给那个平面应用一个自定义 shader。给那个自定义 shader,我们发送包含粒子位置的 FBO 纹理。但自定义 shader 不只是显示纹理,它还更新像素,从而更新粒子。

这就是我们把 RGB 通道当作 XYZ 坐标、为像素计算并应用流场的地方。

然后,在渲染真实场景之前,我们用离屏相机渲染那个离屏场景,并把结果保存到一个 FBO 里。

对于下一帧,我们把之前渲染的 FBO 的结果发给平面 shader,在再次对像素应用流场的同时完成一次新的渲染。

然后再下一帧。再一帧,再一帧。

我们最终得到一个包含粒子坐标的 FBO(一张纹理),同时像素在每一帧都被更新。多亏了 GPU,这些更新在眨眼之间完成。

这是理论;不幸的是,实际上还要复杂得多。

首先,我们不能在同一个 FBO 里读写,这就是为什么我们需要两个,并在每次更新时互换它们。我们把这种做事方式叫做“乒乓缓冲”(ping-pong buffers)。

其次,把像素当数据用很困难,因为像素可以有各种格式和类型。

最后,我们必须完成整个设置,在它生效之前屏幕上几乎什么都没有。这就是为什么调试 GPGPU 非常复杂。

GPUComputationRenderer 来救场

幸运的是,有一个 Three.js 类可以解决那个问题。

GPUComputationRenderer 会替你做大部分繁重的工作,比如创建场景、处理乒乓缓冲、正确设置颜色格式、渲染等等。

在我写这时,这个类的文档还不够完善,Three.js 文档里其实根本没有对应页面。

幸运的是,代码注释很完善:GPUComputationRenderer.js

而且有一些例子:https://threejs.org/examples/?q=gpgpu#webgl_gpgpu_birds

总之,你会在这里学会怎么用它。

GPGPU

我们会先设置 GPGPU,并暂时在这个简单球体上测试它。之后,我们会切换到船的模型。

小心一点,尽量不要偏离课程,至少在你让粒子动起来之前。尽管 GPUComputationRenderer 让事情变简单了,但前面还有大量代码要写。

几何体

首先,我们需要对我们的代码结构做一些小改动。

我们想实例化 GPUComputationRenderer,但它最前面的参数是宽和高。这个大小会在内部用于创建 FBO,它取决于粒子的数量,因为我们需要每个粒子对应一个像素。

我们通过几何体知道粒子的数量,这就是为什么我们要先声明几何体。

就在 Particles 部分之前,创建一个 Base geometry 部分,并创建一个 baseGeometry 对象:

/**
 * Base geometry
 */
const baseGeometry = {}

/**
 * Particles
 */
// ...

把 SphereGeometry 实例放进一个 instance 属性:

const baseGeometry = {}
baseGeometry.instance = new THREE.SphereGeometry(3)

把粒子数量保存为 count 属性,用 baseGeometry.instance.attributes.position.count:

const baseGeometry = {}
baseGeometry.instance = new THREE.SphereGeometry(3)
baseGeometry.count = baseGeometry.instance.attributes.position.count

在 Particles 部分里,移除 particles.geometry,并在 Points 上使用 baseGeometry.instance:

/**
 * Particles
 */
const particles = {}

// Material
// ...

// Points
particles.points = new THREE.Points(baseGeometry.instance, particles.material)
scene.add(particles.points)

我们得到完全一样的结果,但在 Base geometry 和 Particles 部分之间,我们要设置我们的 GPUComputationRenderer。

GPUComputationRenderer

就在 Particles 部分之前,创建一个带 gpgpu 对象的 GPU Compute 部分:

/**
 * GPU Compute
 */
// Setup
const gpgpu = {}

我们用对象来组织和分隔事物。

我们想实例化 GPUComputationRenderer。从 three/addons/misc/GPUComputationRenderer.js 导入它:

import { GPUComputationRenderer } from 'three/addons/misc/GPUComputationRenderer.js'

记住 FBO 的每个像素会对应一个粒子。给你举个例子:如果我们有 9 个粒子,我们需要 FBO 上有 9 个像素。因为 FBO 就像 2D 渲染,它们是矩形的。为了让计算更简单,我们会把它们当作正方形。

对于 9 个粒子,我们需要一个 3 乘 3 的 FBO,我们可以通过平方根来算出来。

9 的平方根等于 3:

但 10 个粒子呢?

10 的平方根等于 3.1622。我们需要一个整数,可以向上取整到 4,这样每个粒子都能放下:

我们会多出一些剩下的像素,但这不是什么大问题。

创建一个 size 属性,并把 baseGeometry.count 的 Math.sqrt() 再取 Math.ceil() 的结果赋给它:

const gpgpu = {}
gpgpu.size = Math.ceil(Math.sqrt(baseGeometry.count))

我们可以实例化 GPUComputationRenderer,并把它赋给一个 computation 属性。前两个参数是宽和高,两者都是 gpgpu.size(它是正方形)。第三个参数是 renderer:

const gpgpu = {}
gpgpu.size = Math.ceil(Math.sqrt(baseGeometry.count))
gpgpu.computation = new GPUComputationRenderer(gpgpu.size, gpgpu.size, renderer)

基础位置

我们需要把基础粒子作为一张纹理发给那个 GPUComputationRenderer。它是用于第一次渲染、然后被反复更新的基础纹理。

我们可以用 createTexture() 方法创建一张纹理:

// Base particles
const baseParticlesTexture = gpgpu.computation.createTexture()

让我们用一个 console.log() 看看里面:

const baseParticlesTexture = gpgpu.computation.createTexture()
console.log(baseParticlesTexture)

它是一个 DataTexture,和其他 Three.js 纹理类似,但像素数据被设置为一个数组,我们可以在 baseParticlesTexture.image.data 里访问它:

const baseParticlesTexture = gpgpu.computation.createTexture()
console.log(baseParticlesTexture.image.data)

我们得到一堆 0。每一组 4 个值会对应一个粒子,因为每组 4 个值是某个像素的 r、g、b 和 a 通道。

让我们暂时让 baseParticlesTexture 保持这样,之后我们会把粒子位置放进去,代替那些 0。

Particles 变量

GPUComputationRenderer 的工作方式是,每一种将被计算的数据类型被称为一个“variable”(变量)。在我们的情况里,我们只有一个变量,就是粒子。

要创建一个变量,我们发送之前创建的基础纹理(baseParticlesTexture)。此外,我们还需要发送一个会更新那张纹理的 shader。

让我们创建 shader。在 src/shaders/ 里,创建一个 gpgpu/ 文件夹。在那个 gpgpu/ 文件夹里,创建一个 particles.glsl 文件,并加入把每个像素涂成红色的最小 fragment shader 代码:

void main()
{
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}

是的,这意味着目前所有粒子都在 1.0, 0.0, 0.0 坐标处。

回到 script.js:把 shader 导入为 gpgpuParticlesShader:

import gpgpuParticlesShader from './shaders/gpgpu/particles.glsl'

用 addVariable() 方法创建一个“variable”,第一个参数传 'uParticles',第二个参数传 gpgpuParticlesShader shader,第三个参数传 baseParticlesTexture。把它保存在 gpgpu.particlesVariable:

// Base particles
const baseParticlesTexture = gpgpu.computation.createTexture()

// Particles variable
gpgpu.particlesVariable = gpgpu.computation.addVariable('uParticles', gpgpuParticlesShader, baseParticlesTexture)

'uParticles' 是我们给纹理选的名字。这意味着,在我们提供的 shader 里,我们可以用 uParticles 访问这张纹理。

如前所述,我们希望粒子的位置在时间上持久化,意味着它们的坐标会被保存并在下一帧重新使用。

这个“variable”需要被重新注入到它自己里。为此,我们可以用 setVariableDependencies() 方法。

第一个参数是变量(在我们的情况里是 gpgpu.particlesVariable),第二个参数是一个包含依赖项的数组(同一个 gpgpu.particlesVariable):

// Particles variable
gpgpu.particlesVariable = gpgpu.computation.addVariable('uParticles', gpgpuParticlesShader, baseParticlesTexture)
gpgpu.computation.setVariableDependencies(gpgpu.particlesVariable, [ gpgpu.particlesVariable ])

这就是我们创建循环的方式,它让我们能不断发送和更新同一张纹理。

我们的 GPUComputationRenderer 准备好了,我们可以用 init() 方法初始化它:

// Init
gpgpu.computation.init()

我们现在想在每一帧更新我们的 particles 变量,只要在 tick 函数里、最终渲染之前,在 GPUComputationRenderer 实例上调用 compute() 方法就行:

const tick = () =>
{
    // ...

    // GPGPU Update
    gpgpu.computation.compute()

    // Render normal scene
    // ...
}

调试

那是很多代码,它可能还很模糊,而且我们仍然没有在屏幕上看到任何东西。

在使用 GPUComputationRenderer 的输出到粒子之前,我们要测试里面有什么。

说到底,我们想从 GPUComputationRenderer 得到一张包含粒子坐标的纹理。我们要创建一个平面并把 GPUComputationRenderer 的输出纹理应用到它,就为了看看我们得到什么。

回到 GPU Compute 部分:用 PlaneGeometry 和 MeshBasicMaterial 创建一个调试平面,把它移到旁边,并添加到 scene:

// Debug
gpgpu.debug = new THREE.Mesh(
    new THREE.PlaneGeometry(3, 3),
    new THREE.MeshBasicMaterial()
)
gpgpu.debug.position.x = 3
scene.add(gpgpu.debug)

我们想把 GPUComputationRenderer 的输出纹理应用到那个平面,我们可以用 getCurrentRenderTarget() 访问它。给这个方法,我们需要发送变量,在我们的情况里是 gpgpu.particlesVariable。

出于好奇,console.log() 它:

console.log(gpgpu.computation.getCurrentRenderTarget(gpgpu.particlesVariable))

我们得到一个 WebGLRenderTarget,它是处理 FBO 的 Three.js 类。

我们可以用 texture 属性访问实际的纹理:

console.log(gpgpu.computation.getCurrentRenderTarget(gpgpu.particlesVariable).texture)

我们可以把那张纹理赋给 MeshBasicMaterial 的 map 属性:

gpgpu.debug = new THREE.Mesh(
    new THREE.PlaneGeometry(3, 3),
    new THREE.MeshBasicMaterial({ map: gpgpu.computation.getCurrentRenderTarget(gpgpu.particlesVariable).texture })
)
gpgpu.debug.position.x = 3
scene.add(gpgpu.debug)

我们得到一个全红的平面,因为我们的 particles.glsl shader 只输出红色,目前来说没问题。

发送并更新粒子

我们想要的是把粒子作为像素,而不是那个统一的红色。创建 particles 变量时,我们已经把 baseParticlesTexture 发给了 shader,但它目前被一堆 0 填满。让我们填充它。

我们想把每个粒子的坐标(x、y 和 z)配置为 r、g 和 b 通道,暂时忽略 a 通道。

就在创建 baseParticlesTexture 之后,用一个经典的 for(…) 从 0 循环到 baseGeometry.count:

// Base particles
const baseParticlesTexture = gpgpu.computation.createTexture()

for(let i = 0; i < baseGeometry.count; i++)
{
}

几何体的粒子位于 baseGeometry.instance.attributes.position.array,以 3 个为一组,因为 x、y 和 z。

我们需要更新的数组位于 baseParticlesTexture.image.data,值以 4 个为一组,因为 r、g、b 和 a。

我们需要一个 i3 和一个 i4:

for(let i = 0; i < baseGeometry.count; i++)
{
    const i3 = i * 3
    const i4 = i * 4
}

我们现在可以把 baseGeometry.instance.attributes.position.array 里的值分别用 i3 和 i4 放进 baseParticlesTexture.image.data,并暂时把第四个值(alpha)设为 0:

for(let i = 0; i < baseGeometry.count; i++)
{
    const i3 = i * 3
    const i4 = i * 4

    // Position based on geometry
    baseParticlesTexture.image.data[i4 + 0] = baseGeometry.instance.attributes.position.array[i3 + 0]
    baseParticlesTexture.image.data[i4 + 1] = baseGeometry.instance.attributes.position.array[i3 + 1]
    baseParticlesTexture.image.data[i4 + 2] = baseGeometry.instance.attributes.position.array[i3 + 2]
    baseParticlesTexture.image.data[i4 + 3] = 0
}

用一个 console.log() 看看 baseParticlesTexture.image.data,你应该有 3 个值,然后一个 0,如此反复:

const baseParticlesTexture = gpgpu.computation.createTexture()

for(let i = 0; i < baseGeometry.count; i++)
{
    // ...
}
console.log(baseParticlesTexture.image.data)
lesson demo video preview

最后的一些值都是 0,因为那些像素没有关联粒子。

在 particles.glsl 里,我们现在想取回纹理。我们选了名字 'uParticles',GPUComputationRenderer 自动把纹理注入为 sampler2D uParticles。

但现在,我们想从那个 uParticles 里取样颜色。

我们需要经典的 UV 坐标,可以用下面的公式 gl_FragCoord.xy / resolution.xy 得到它们。

我们在 Halftone 那节课见过 gl_FragCoord.xy。它是屏幕坐标,对应从底部左侧开始的像素:

resolution.xy 包含渲染的大小,由于我们用它来除 gl_FragCoord.xy,我们得到 0 到 1 的 UV 坐标。

在 main() 开头把它保存为一个 float uv 变量:

void main()
{
    vec2 uv = gl_FragCoord.xy / resolution.xy;
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
}

把它作为 gl_FragColor 的前几个值发出去,并保持其他值为 1.0:

void main()
{
    vec2 uv = gl_FragCoord.xy / resolution.xy;
    gl_FragColor = vec4(uv, 1.0, 1.0);
}

我们得到经典的 UV 坐标,我们可以用它们配合 texture() 函数从 uPosition 里取样颜色:

void main()
{
    vec2 uv = gl_FragCoord.xy / resolution.xy;
    vec4 particle = texture(uParticles, uv);
    gl_FragColor = vec4(uv, 1.0, 1.0);
}

把整个结果发给 gl_FragColor:

void main()
{
    vec2 uv = gl_FragCoord.xy / resolution.xy;
    vec4 particle = texture(uParticles, uv);
    gl_FragColor = particle;
}

虽然它看起来可能有 bug,但平面的每个方块对应 uParticles 纹理的一个像素,而那个像素本身对应每个粒子的坐标。

酷的部分是数据在持久化,意味着如果我们更新 particle,我们会在下一帧得到更新后的版本,而且我们可以不断更新它。

举个例子,给 y 属性加上 0.01:

void main()
{
    vec2 uv = gl_FragCoord.xy / resolution.xy;
    vec4 particle = texture(uParticles, uv);
    particle.y += 0.01;
    gl_FragColor = particle;
}
lesson demo video preview

像素变得更绿。由于绿色对应 y 坐标,这意味着粒子会上移。

注意 rgba 和 xyzw 是可以互换的。用其中一个或另一个效果相同。但由于我们把像素当作粒子,用 xyz 更合理。

几秒后,像素停止变化,你可能以为粒子也会停止移动,但值实际上不断变高。只是颜色通道在视觉上被限制在 0 到 1 的范围内。

用 GPGPU 定位粒子

我们的 GPGPU 虽然非常基础,但已经准备好了,我们现在想用那张纹理来定位粒子。

目前,粒子在实例化 Points 时用的是 baseGeometry.instance 作为几何体,但我们想改用 GPGPU 纹理。

让我们从一个空几何体开始。在 Particles 部分里,创建一个 BufferGeometry,把它保存为 particles.geometry,并把它发给 Points:

/**
 * Particles
 */
// ...

// Geometry
particles.geometry = new THREE.BufferGeometry()

// ...

// Points
particles.points = new THREE.Points(particles.geometry, particles.material)
scene.add(particles.points)

粒子不见了。

默认情况下,Three.js 用 position attribute 里的项数来知道要画多少个点。然而,我们不想再依赖 position attribute 了。

幸运的是,我们可以用几何体上的 setDrawRange() 方法来定义一个顶点范围:

// Geometry
particles.geometry = new THREE.BufferGeometry()
particles.geometry.setDrawRange(0, baseGeometry.count)

目前场景中心有几千个粒子,我们要用 GPGPU 来移动它们。

在 ShaderMaterial 里,用 Uniform 类发送一个 uParticlesTexture uniform,暂时保持为空:

particles.material = new THREE.ShaderMaterial({
    // ...
    uniforms:
    {
        // ...
        uParticlesTexture: new THREE.Uniform()
    }
})

在 tick 函数里,用我们之前访问 GPGPU 的 FBO 时用的同样技术,通过 getCurrentRenderTarget() 方法更新 uParticlesTexture uniform,别忘了我们要的是 texture 属性:

const tick = () =>
{
    // ...

    // GPGPU Update
    gpgpu.computation.compute()
    particles.material.uniforms.uParticlesTexture.value = gpgpu.computation.getCurrentRenderTarget(gpgpu.particlesVariable).texture

    // ...
}

我们需要在每一帧发送 RenderTarget 纹理,因为 GPUComputationRenderer 为乒乓缓冲使用了多个 FBO,而我们需要计算这一帧时所用的那一个。

在粒子的 vertex shader 里,我们想从 uParticlesTexture 里取样纹理,就像我们在 particles.glsl 里做的那样,但这一次,我们不能用 gl_FragCoord.xy,因为那些是屏幕坐标。

取而代之,我们要自己把 UV 坐标作为一个 attribute 发送。

在 Particles 的 Geometry 部分,在创建 BufferGeometry 之前,用一个长度为 baseGeometry.count * 2 的 Float32Array 创建一个 particlesUvArray 数组:

// Geometry
const particlesUvArray = new Float32Array(baseGeometry.count * 2)

particles.geometry = new THREE.BufferGeometry()

我们现在想用对应 uParticlesTexture 里像素的 UV 坐标填充 particlesUvArray。

为了理解接下来的部分,这里是我们如果有 9 个粒子(3 乘 3)所需要的坐标:

紫色的叉在每个格子的中心。

要达到那里,我们要先为 y 坐标(行)做一个 … for … 循环,并在那个 … for … 循环里,为 x 坐标(列)做第二个 … for … 循环:

const particlesUvArray = new Float32Array(baseGeometry.count * 2)

for(let y = 0; y < gpgpu.size; y++)
{
    for(let x = 0; x < gpgpu.size; x++)
    {
    }
}

在里面,我们仍然需要访问一个从 0 到粒子数量的索引,我们可以通过把 y 乘以 gpgpu.size 再加上 x 来做到。把结果保存为 i:

for(let y = 0; y < gpgpu.size; y++)
{
    for(let x = 0; x < gpgpu.size; x++)
    {
        const i = (y * gpgpu.size + x)
    }
}

由于填充 particlesUvArray 时每个顶点需要两个值,让我们把 i 乘以 2 算出一个 i2:

for(let y = 0; y < gpgpu.size; y++)
{
    for(let x = 0; x < gpgpu.size; x++)
    {
        const i = (y * gpgpu.size + x)
        const i2 = i * 2
    }
}

目前,x 和 y 从 0 到 gpgpu.size,但我们需要它们从 0 到 1(不含),因为我们想要 UV 坐标。

要做到这一点,创建 uvX 和 uvY 变量,并把 x 和 y 除以 gpgpu.size:

for(let y = 0; y < gpgpu.size; y++)
{
    for(let x = 0; x < gpgpu.size; x++)
    {
        const i = (y * gpgpu.size + x)
        const i2 = i * 2

        const uvX = x / gpgpu.size
        const uvY = y / gpgpu.size
    }
}

我们快到了,不幸的是,那些 uvX 和 uvY 坐标不在格子的中心。相反,它们目前在左下角:

要修复那个,我们只需要在除以 gpgpu.size 之前给 x 和 y 加上 0.5:

for(let y = 0; y < gpgpu.size; y++)
{
    for(let x = 0; x < gpgpu.size; x++)
    {
        const i = (y * gpgpu.size + x)
        const i2 = i * 2

        // Particles UV
        const uvX = (x + 0.5) / gpgpu.size
        const uvY = (y + 0.5) / gpgpu.size
    }
}

就这样。我们现在可以把 uvX 和 uvY 分别保存在 particlesUvArray 的 i2 和 i2 + 1 索引处:

for(let y = 0; y < gpgpu.size; y++)
{
    for(let x = 0; x < gpgpu.size; x++)
    {
        const i = (y * gpgpu.size + x)
        const i2 = i * 2

        // Particles UV
        const uvX = (x + 0.5) / gpgpu.size
        const uvY = (y + 0.5) / gpgpu.size

        particlesUvArray[i2 + 0] = uvX;
        particlesUvArray[i2 + 1] = uvY;
    }
}

在 setDrawRange() 之后,用 setAttribute() 方法创建一个新 attribute,把它命名为 'aParticlesUv',并把 BufferAttribute 大小设为 2:

particles.geometry = new THREE.BufferGeometry()
particles.geometry.setDrawRange(0, baseGeometry.count)
particles.geometry.setAttribute('aParticlesUv', new THREE.BufferAttribute(particlesUvArray, 2))

在 particles/vertex.glsl 里,把 uParticlesTexture uniform 取回为 sampler2D,把 aParticlesUv attribute 取回为 vec2:

uniform sampler2D uParticlesTexture;

attribute vec2 aParticlesUv;

我们现在可以用 texture() 在 aParticlesUv 坐标处从 uParticlesTexture 取样颜色:

void main()
{
    vec4 particle = texture(uParticlesTexture, aParticlesUv);
    
    // ...
}

接下来,用它作为 modelPosition,代替 position,通过 swizzle 出 xyz,因为我们只想要位置:

void main()
{
    vec4 particle = texture(uParticlesTexture, aParticlesUv);

    // Final position
    vec4 modelPosition = modelMatrix * vec4(particle.xyz, 1.0);
    // ...
}
lesson demo video preview

粒子位置现在完全由 GPGPU 控制,更确切地说,由 particles.glsl shader 控制。

你可以在 gpgpu/particles.glsl 里,把 particle.y += 改成 particle.x += 试试:

void main()
{
    // ...
    particle.x += 0.01;
    // ...
}
lesson demo video preview

与其做那个无聊的过渡,我们要实现流场。但首先我们需要更多准备。

使用模型

我们要用在 SketchFab 上能找到的那个模型:https://sketchfab.com/3d-models/mv-spartan-e2c3ced464f14e3b864f15871bf6d87d

它是一个基于照片的网格重建。我们把这种技术叫做摄影测量(photogrammetry)。

该模型在 CCO 许可下,顶点数量还算可以。

我把它导入 Blender,稍微变换了一下,并把纹理烘焙进了顶点颜色。

你可以在 Blender 文件里用 Resources 按钮找到修改后的版本。

加载

我们要加载模型,并用它的几何体代替 SphereGeometry。如你所知,加载模型需要时间。这意味着我们需要等模型加载完,再初始化 GPGPU 和粒子。

我们最终会在回调函数里有大量代码,但其实有一个叫“异步加载”的技巧能让我们摆脱回调函数。

在 Base Geometry 部分之前,用下面的指令加载模型,并 console.log() 结果:

/**
 * Load model
 */
const gltf = await gltfLoader.loadAsync('./model.glb')
console.log(gltf)
lesson demo video preview

我们立即得到 gltf,没有任何回调函数。

这里发生的是我们用了 loadAsync(),它返回一个 Promise。事实上,我们用 await 关键字让代码等待 Promise 被解决后再继续。

Promises、await 和 async 属于一个全新又复杂的话题,我们不会在这里讲,但用法就像现在演示的这样简单。

需要理解的一点是,在这里使用 await 之所以可行,是因为 Vite 创建了一个 async 作用域,允许我们在顶层使用 await。如果你尝试用 npm run build 构建项目,或者尝试用 Vercel 上线(它也会依赖 npm run build),你很可能会得到一个错误,因为 Vite 试图让你的项目兼容大多数浏览器,而顶层 await 为了兼容性并非在所有目标浏览器上都可用。

要修复那个,我们可以简单地改变目标浏览器列表。在 vite.config.js 文件里,在 build 属性中,添加 target: 'esnext':

export default {
    // ...
    build:
    {
        // ...
        target: 'esnext'
    },
    // ...
}

你的构建现在应该能工作了,但 Vite 不会尝试向下转译到旧浏览器,这应该没问题。

另一个缺点是,如果模型加载需要时间,代码会在这里卡住一会儿,用户在此期间看不到任何东西。

就轻量级模型的实验而言,这没问题,但除此之外,你应该用更合适的方案,我在 Code structuring for bigger projects 那节课里讲过。

不管怎样,我们现在把加载好的模型作为 gltf,我们可以从 scene 的第一个也是唯一一个子对象里提取几何体。然后,我们把它保存在 baseGeometry.instance 里,代替 SphereGeometry:

/**
 * Base geometry
 */
const baseGeometry = {}
baseGeometry.instance = gltf.scene.children[0].geometry
lesson demo video preview

我们得到了船,但粒子太大了。把 uSize 降到 0.07:

particles.material = new THREE.ShaderMaterial({
    // ...
    uniforms:
    {
        uSize: new THREE.Uniform(0.07),
        // ...
    }
})

颜色

如前所述,我直接把颜色保存在了顶点颜色里,我们可以用 baseGeometry.instance.attributes.color 访问它。

就在设置 'aParticlesUv' attribute 之后,我们可以设置一个名为 'aColor' 的新 attribute,并把 baseGeometry.instance.attributes.color 发过去,它本身已经是一个 BufferAttribute:

particles.geometry.setAttribute('aParticlesUv', new THREE.BufferAttribute(particlesUvArray, 2))
particles.geometry.setAttribute('aColor', baseGeometry.instance.attributes.color)

在 particles/vertex.glsl 里,把 aColor attribute 取回为 vec3,并转发给 vColor:

// ...
attribute vec3 aColor;

// ...

void main()
{
    // ...
    vColor = aColor;
}

随机大小

既然在弄 attributes,让我们把粒子的大小随机化。

创建 particlesUvArray 之后,用一个大小为 baseGeometry.count 的 Float32Array 创建一个 sizesArray 数组:

// Geometry
const particlesUvArray = new Float32Array(baseGeometry.count * 2)
const sizesArray = new Float32Array(baseGeometry.count)

在填充 particlesUvArray 数组的那个双重 … for … 里,用一堆 Math.random() 填充 sizesArray 的 i 索引处,因为每个顶点我们只有一个值:

for(let y = 0; y < gpgpu.size; y++)
{
    for(let x = 0; x < gpgpu.size; x++)
    {
        // ...

        // Size
        sizesArray[i] = Math.random()
    }
}

设置一个名为 'aSize' 的新 attribute,用一个 BufferAttribute,把 sizesArray 传给它,并把它的 item size 设为 1:

particles.geometry.setAttribute('aSize', new THREE.BufferAttribute(sizesArray, 1))

在 particles/vertex.glsl 里,把 aSize attribute 取回为 float,并把它乘进 gl_PointSize:

// ...
attribute float aSize;

// ...

void main()
{
    // ...

    // Point size
    gl_PointSize = aSize * uSize * uResolution.y;
    gl_PointSize *= (1.0 / - viewPosition.z);

    // ...
}

流场动画

设置 GPGPU 花了大量准备工作,但现在,我们准备好创建流场并替换这个无聊的动画了。

如前所述,流场对应一种空间化的流。对于任意 3D 点,我们计算一个方向,并用那个方向移动粒子。有些会朝一个方向移动,而另一些会朝不同方向移动。为了让那些方向感觉自然、平滑变化,我们要用前面课程里发现的 Simplex Noise。

由于 Simplex Noise 允许超过 3 个维度,我们会用一个 4D Simplex Noise,让流在时间上也变化。

4D Simplex Noise 已经在 src/shaders/includes/simplexNoise4d.glsl 里可用了,取自常用的 Gist:https://gist.github.com/patriciogonzalezvivo/670c22f3966e662d2f83

在 gpgpu/particles.glsl 里,移除 particle.x += 0.01;:

void main()
{
    vec2 uv = gl_FragCoord.xy / resolution.xy;
    vec4 particle = texture(uParticles, uv);
    gl_FragColor = particle;
}

在 main() 之前,包含 simplexNoise4d.glsl:

#include ../includes/simplexNoise4d.glsl

void main()
{
    // ...
}

由于流场是一个 3D 方向,我们要创建一个 vec3 flowField(先别保存):

void main()
{
    vec2 uv = gl_FragCoord.xy / resolution.xy;
    vec4 particle = texture(uParticles, uv);

    // Flow field
    vec3 flowField = vec3();

    gl_FragColor = particle;
}

在 vec3() 的 3 个轴上分别调用 simplexNoise4d(),每个都发送一个 vec4(),因为它是 4D Simplex Noise(先别保存):

void main()
{
    vec2 uv = gl_FragCoord.xy / resolution.xy;
    vec4 particle = texture(uParticles, uv);

    // Flow field
    vec3 flowField = vec3(
        simplexNoise4d(vec4()),
        simplexNoise4d(vec4()),
        simplexNoise4d(vec4())
    );

    gl_FragColor = particle;
}

对于 vec4() 的前 3 个值,我们会用粒子位置,通过 swizzle 出 xyz,第四个值暂时设为 0.0:

void main()
{
    vec2 uv = gl_FragCoord.xy / resolution.xy;
    vec4 particle = texture(uParticles, uv);
    
    // Flow field
    vec3 flowField = vec3(
        simplexNoise4d(vec4(particle.xyz, 0.0)),
        simplexNoise4d(vec4(particle.xyz, 0.0)),
        simplexNoise4d(vec4(particle.xyz, 0.0))
    );

    gl_FragColor = particle;
}

由于它是一个方向,让我们 normalize 它,使其长度始终为 1:

void main()
{
    // ...
    
    // Flow field
    vec3 flowField = vec3(
        simplexNoise4d(vec4(particle.xyz, 0.0)),
        simplexNoise4d(vec4(particle.xyz, 0.0)),
        simplexNoise4d(vec4(particle.xyz, 0.0))
    );
    flowField = normalize(flowField);

    // ...
}

移动粒子就是把 flowField 加到 particle.xyz 上那么简单:

void main()
{
    // ...
    
    // Flow field
    vec3 flowField = vec3(
        simplexNoise4d(vec4(particle.xyz, 0.0)),
        simplexNoise4d(vec4(particle.xyz, 0.0)),
        simplexNoise4d(vec4(particle.xyz, 0.0))
    );
    flowField = normalize(flowField);
    particle.xyz += flowField;

    // ...
}
lesson demo video preview

不是预期的结果。我们有几个问题要解决。

首先,所有粒子都朝对角线方向移动,因为我们给三个 simplexNoise4d() 发送了相同的坐标。

我们需要发送不同的值,我们可以简单地把它们稍微偏移:

void main()
{
    // ...
    
    // Flow field
    vec3 flowField = vec3(
        simplexNoise4d(vec4(particle.xyz + 0.0, 0.0)),
        simplexNoise4d(vec4(particle.xyz + 1.0, 0.0)),
        simplexNoise4d(vec4(particle.xyz + 2.0, 0.0))
    );

    // ...
}
lesson demo video preview

现在效果太强了。

通过把它应用到 particle.xyz 时把 flowField 乘以 0.01 来减弱:

void main()
{
    // ...
    particle.xyz += flowField * 0.01;

    // ...
}
lesson demo video preview

现在我们得到了看起来像流场的东西,但几秒后,粒子似乎卡在循环里。

这是因为流场在时间上保持不变,粒子最终会走同一条路径。我们想让 Simplex Noise 随时间变化。这正是 vec4 第四个值可以用到的地方。但由于我们想让它随时间变化,我们需要把那个时间发给 shader。

我们想添加一个 uTime uniform,但小心,我们想把它设在处理粒子的 GPGPU 变量上,而不是最终的粒子 ShaderMaterial 上。

要做到这一点,你可以用 gpgpu.particlesVariable.material 访问处理粒子的 GPGPU ShaderMaterial,它和任何 ShaderMaterial 一样,意味着它有一个 uniforms 属性。

在 gpgpu.particlesVariable.material.uniforms 上,用 Uniform 添加一个 uTime uniform,并设为 0:

// Particles variable
gpgpu.particlesVariable = gpgpu.computation.addVariable('uParticles', gpgpuParticlesShader, baseParticlesTexture)
gpgpu.computation.setVariableDependencies(gpgpu.particlesVariable, [ gpgpu.particlesVariable ])

// Uniforms
gpgpu.particlesVariable.material.uniforms.uTime = new THREE.Uniform(0)

在 tick 里,在 compute() 之前,用 elapsedTime 更新它的 value:

const tick = () =>
{
    // ...

    // GPGPU Update
    gpgpu.particlesVariable.material.uniforms.uTime.value = elapsedTime
    gpgpu.computation.compute()
    // ...
}

回到 gpgpu/particles.glsl:把 uTime uniform 取回为 float:

uniform float uTime;

对于我们要做的事,uTime 会有点快。由于我们会在代码里多处用到它,让我们为它创建一个变量。

在 main() 开头,创建一个 float time 变量,并把 uTime 乘以 0.2 赋给它:

void main()
{
    float time = uTime * 0.2;
    // ...
}

把它作为 simplexNoise4d() 的第四个值,代替 0.0:

void main()
{
    // ...
    
    // Flow field
    vec3 flowField = vec3(
        simplexNoise4d(vec4(particle.xyz + 0.0, time)),
        simplexNoise4d(vec4(particle.xyz + 1.0, time)),
        simplexNoise4d(vec4(particle.xyz + 2.0, time))
    );
    // ...
}
lesson demo video preview

粒子的生命

我们刚刚创造了美丽而永续的混沌,但几秒后,我们就失去了船的形状。

我们希望粒子过完它们的一生,然后在片刻之后回到初始位置,重新开始四处移动。

每个粒子会有自己的生命跨度,这意味着我们需要让这份数据持久化。

要是我们除了 rgb 值之外还有一个值就好了……你猜对了,我们要用 alpha 通道。

衰减

对每个粒子来说,它的生命从 a 等于 0.0 开始,每帧递增,当到达 1 时,它会重置回初始位置,并让它的 a 回到 0.0,准备好重新开始。

在 gpgpu/particles.glsl 里,更新 particle.xyz 之后,把 a 递增 0.01:

void main()
{
    // ...
    
    // Decay
    particle.a += 0.01;

    gl_FragColor = particle;
}

现在我们要用一个条件来判定粒子是否死亡,也就是当 particle.a 到达 1.0 或以上时。

如果它死了,目前我们只把 a 重置为 0.0;否则,我们应用流场和衰减:

void main()
{
    // ...
    
    // Dead
    if(particle.a >= 1.0)
    {
        particle.a = 0.0;
    }

    // Alive
    else
    {
        // Flow field
        vec3 flowField = vec3(
            simplexNoise4d(vec4(particle.xyz + 0.0, time)),
            simplexNoise4d(vec4(particle.xyz + 1.0, time)),
            simplexNoise4d(vec4(particle.xyz + 2.0, time))
        );
        flowField = normalize(flowField);
        particle.xyz += flowField * 0.01;
        
        // Decay
        particle.a += 0.01;
    }

    gl_FragColor = particle;
}

确保把 gl_FragColor 的更新放在 if 和 else 语句之外。

lesson demo video preview

目前还没有区别,因为我们做的只是在粒子死亡时把 a 还原回 0.0。

重置

我们现在想重置位置,但我们访问不到初始位置。尽管在第一帧 uParticles 包含初始位置,但它在每次 compute() 之后都会被更新。

要修复那个,我们要把 baseParticlesTexture(包含初始位置的那张纹理)作为一个新 uniform 发出去。

在 script.js 里,创建 uTime uniform 之后,用 Uniform 类创建一个 uBase uniform,并把 baseParticlesTexture 发给它:

// Uniforms
// ...
gpgpu.particlesVariable.material.uniforms.uBase = new THREE.Uniform(baseParticlesTexture)

在 particles.glsl 里,把 uBase uniform 取回为 sampler2D:

uniform sampler2D uBase;

从 uParticles 取回 particle 之后,对 uBase 做同样的事,并把它保存为 vec4 base:

void main()
{
    // ...
    vec4 particle = texture(uParticles, uv);
    vec4 base = texture(uBase, uv);
    // ...
}

我们本可以把 base 放进 if 里,但放在前面之后会派上用场。

在 if 里(当粒子死亡时),把 particle 的 xyz 设为 base.xyz:

void main()
{
    // ...
    vec4 base = texture(uBase, uv);
    
    // Dead
    if(particle.a >= 1.0)
    {
        particle.a = 0.0;
        particle.xyz = base.xyz;
    }
    // ...
}
lesson demo video preview

随机化

所有粒子都在同一时间死亡,因为它们都从 0 开始,每帧被更新同样的量,然后一起死亡。

要修复那个,在填充 baseParticlesTexture 时,把赋给 alpha 通道的 0 改成 Math.random():

for(let i = 0; i < baseGeometry.count; i++)
{
    // ...
    baseParticlesTexture.image.data[i4 + 3] = Math.random()
}
lesson demo video preview

一种汹涌而迷人的混沌。

修复帧率

你们中一些使用高刷新率显示器的人可能已经发现一个问题:粒子死得更快了。

这是因为我们每帧都把生命递增 0.01,而不考虑帧率。

我们需要使用 delta time。

就像我们把 uTime 发给 particlesVariable 材质 uniforms 一样,发一个 uDeltaTime,并设为 0:

// Uniforms
// ...
gpgpu.particlesVariable.material.uniforms.uDeltaTime = new THREE.Uniform(0)

在 tick 里,更新 uDeltaTime.value,把已经算好的 deltaTime 赋给它:

const tick = () =>
{
    // ...

    // GPGPU Update
    gpgpu.particlesVariable.material.uniforms.uTime.value = elapsedTime
    gpgpu.particlesVariable.material.uniforms.uDeltaTime.value = deltaTime
    // ...
}

在 particles.glsl 里,把 uDeltaTime uniform 取回为 float:

uniform float uDeltaTime;

在给 particle.a 应用衰减时,用 uDeltaTime 并把它乘以 0.3,这样它不会太慢:

void main()
{
    // ...

    // Alive
    else
    {
        // ...

        // Decay
        particle.a += uDeltaTime * 0.3;
    }

    // ...
}
lesson demo video preview

既然到这里了,我们在把 flowField 应用到 particle.xyz 时也需要用 uDeltaTime,这样无论帧率如何,它们都以相同的速度移动。把它乘以 0.5,让它足够快:

void main()
{
    // ...

    // Alive
    else
    {
        // ...
        particle.xyz += flowField * uDeltaTime * 0.5;

        // Decay
        particle.a += uDeltaTime * 0.3;
    }

    // ...
}
lesson demo video preview

修复长帧

我们需要修复的最后一个 bug,是当我们遇到一个长帧时。

长帧发生在电脑卡顿一会儿、或者我们离开标签页几秒后又回来的时候:

lesson demo video preview

注意,当标签页不活跃时,浏览器处理 tick 更新的方式不同。

所有粒子都同步了,因为有一帧 uDeltaTime 巨大,它们就一起死亡了。这几乎很浪漫,但我们需要修复它。

这个问题有多个解决方案。

最常见的方案是直接在 JavaScript 里确保 deltaTime 永远不会太大。我个人喜欢把它限制在 1/30 秒,因为 30FPS 被认为是体面体验的最低帧率。

但我们不打算用这个技巧。取而代之,我们要在重置 a 通道时用一个模 1。这样,如果 a 超过 1.0,它会简单地循环回 0 加上剩下的部分,并且永远不会超过 1.0:

void main()
{
    // ...
    
    // Dead
    if(particle.a >= 1.0)
    {
        particle.a = mod(particle.a, 1.0);
        // ...
    }

    // ...
}
lesson demo video preview

控制混沌

目前,我们在所有时刻对所有粒子都应用流场。虽然好看,但它不够微妙,我们再也看不到模型了。

我们想只在模型的某些部分应用流场,并且希望它有机地变化。

我们要用另一个 Simplex Noise。

在 else 里,就在 flowField 之前,调用一个 simplexNoise4d(),把 base.xyz 作为前三个值、time + 1.0 作为第四个(这样它就和其他 simplexNoise4d() 不同),并把它保存为 strength:

void main()
{
    // ...

    // Alive
    else
    {
        // Strength
        float strength = simplexNoise4d(vec4(base.xyz, time + 1.0));

        // ...
    }

    // ...
}

Simplex Noise 从 -1.0 到 +1.0,而我们想要从 0.0 到 1.0 的值。这也是用 smoothstep 平滑该值的机会:

void main()
{
    // ...

    // Alive
    else
    {
        // Strength
        float strength = simplexNoise4d(vec4(base.xyz, time + 1.0));
        strength = smoothstep(- 1.0, 1.0, strength);

        // ...
    }

    // ...
}

我们终于可以在应用 flowField 时使用 strength:

void main()
{
    // ...

    // Alive
    else
    {
        // ...
        particle.xyz += flowField * uDeltaTime * strength * 0.5;

        // ...
    }

    // ...
}
lesson demo video preview

它起作用了,但几乎察觉不到,因为即使有些部分只是轻微移动,所有东西也都在动。

但多亏了 smoothstep,我们现在可以控制有多少粒子受流场影响。

举个例子,把 smoothstep 的第一个参数改成 0.0:

void main()
{
    // ...

    // Alive
    else
    {
        // ...
        strength = smoothstep(0.0, 1.0, strength);

        // ...
    }

    // ...
}
lesson demo video preview

值越高,受影响的粒子越少。

把 base.xyz 乘以 0.2 来降低 strength 的频率,这样我们就能得到更大的、受影响的区块:

void main()
{
    // ...

    // Alive
    else
    {
        // Strength
        float strength = simplexNoise4d(vec4(base.xyz * 0.2, time + 1.0));
        // ...
    }

    // ...
}
lesson demo video preview

调节

既然我们可以用 smoothstep 控制受流场影响的粒子数量,就把它加到调试面板里。

我们想要一个从 0 到 1 的调节项,其中 0 导致没有粒子受影响,1 导致所有粒子都受影响。

在 particlesVariable uniforms 里,用 Uniform 添加一个 uFlowFieldInfluence uniform,并设为 0.5:

// Uniforms
// ...
gpgpu.particlesVariable.material.uniforms.uFlowFieldInfluence = new THREE.Uniform(0.5)

在 Tweaks 部分,为 uFlowFieldInfluence 的 'value' 添加一个从 0 到 1 的调节项:

/**
 * Tweaks
 */
// ...
gui.add(gpgpu.particlesVariable.material.uniforms.uFlowFieldInfluence, 'value').min(0).max(1).step(0.001).name('uFlowfieldInfluence')

在 particles.glsl 里,把 uFlowFieldInfluence uniform 取回为 float:

uniform float uFlowFieldInfluence;

uFlowFieldInfluence 从 0 到 1,当它这样变化时,我们希望 smoothstep 的第一个参数从 +1 到 -1。它们范围不同,而且是反过来的。

我们可以用一个重映射函数,但公式没那么复杂。

就在 smoothstep 之前,创建一个 float influence 变量,从 uFlowFieldInfluence 里减去 0.5 让它从 -0.5 到 +0.5,再乘以 -2.0 让它从 +1.0 到 -1.0,把它用在 smoothstep 的第一个参数里:

void main()
{
    // ...

    // Alive
    else
    {
        // Strength
        // ...
        float influence = (uFlowFieldInfluence - 0.5) * (- 2.0);
        strength = smoothstep(influence, 1.0, strength);
        // ...
    }

    // ...
}
lesson demo video preview

生命周期大小

死亡时,粒子会立即弹回它们的原始位置。

我们想控制它们的大小,让它们缓慢长大、保持一会儿大、然后在死亡前开始缩小。

为了让动画感觉更好,我们想让它更平滑:

它和在 Fireworks 那节课里做的非常相似,我们需要在 particles/vertex.glsl 里做(而不是 gpgpu/particles.glsl)。

我们可以把 particle.a 用在一个 smoothstep 上,创建一个对应粒子放大时的 float sizeIn 变量。

然后我们把 particle.a 用在另一个 smoothstep 上,创建一个对应粒子缩小时的 float sizeOut 变量。

最后,我们用 min 计算两者的最小值,把它保存为 size 变量,并把它乘到 gl_PointSize 上:

void main()
{
    // ...

    // Point size
    float sizeIn = smoothstep(0.0, 0.1, particle.a);
    float sizeOut = 1.0 - smoothstep(0.7, 1.0, particle.a);
    float size = min(sizeIn, sizeOut);

    gl_PointSize = size * aSize * uSize * uResolution.y;
    // ...
}
lesson demo video preview

最后的调节

我们的流场动画完成了,但让我们加一些改进。

隐藏调试

首先,把调试平面去掉。

在 gpgpu.debug Mesh 里添加一个值为 false 的 visible 属性:

gpgpu.debug.visible = false

流场强度调节

我们已经有一个流场影响的调节项了。

让我们为流场强度加一个。

在 particlesVariable uniforms 上,用 Uniform 类添加一个 uFlowFieldStrength uniform,并设为 2:

// Uniforms
// ...
gpgpu.particlesVariable.material.uniforms.uFlowFieldStrength = new THREE.Uniform(2)

在 Tweaks 部分,为它添加一个调节项,min 设为 0,max 设为 10:

/**
 * Tweaks
 */
// ...
gui.add(gpgpu.particlesVariable.material.uniforms.uFlowFieldStrength, 'value').min(0).max(10).step(0.001).name('uFlowfieldStrength')

在 gpgpu/particles.glsl 里,把 uFlowFieldStrength uniform 取回为 float,并在把 flowField 加到 particle.xyz 时用它代替 0.5:

// ...
uniform float uFlowFieldStrength;

// ...

void main()
{
    // ...

    // Alive
    else
    {
        // ...
        particle.xyz += flowField * uDeltaTime * strength * uFlowFieldStrength;
        
        // ...
    }

    // ...
}
lesson demo video preview

流场频率调节

就像我们为强度加了一个控制,我们也可以为流场频率加一个控制。

给 particlesVariable uniforms 添加一个 uFlowFieldFrequency uniform,用 Uniform,并设为 0.5:

// Uniforms
// ...
gpgpu.particlesVariable.material.uniforms.uFlowFieldFrequency = new THREE.Uniform(0.5)

在 Tweaks 部分,为 uFlowFieldFrequency 添加一个调节项,min 设为 0,max 设为 1:

/**
 * Tweaks
 */
// ...
gui.add(gpgpu.particlesVariable.material.uniforms.uFlowFieldFrequency, 'value').min(0).max(1).step(0.001).name('uFlowfieldFrequency')

在 gpgpu/particles.glsl 里,把 uFlowFieldFrequency uniform 取回为 float,并在计算 flowField 时把它乘进 particle.xyz:

// ...
uniform float uFlowFieldFrequency;

// ...

void main()
{
    // ...

    // Alive
    else
    {
        // ...
        vec3 flowField = vec3(
            simplexNoise4d(vec4(particle.xyz * uFlowFieldFrequency + 0.0, time)),
            simplexNoise4d(vec4(particle.xyz * uFlowFieldFrequency + 1.0, time)),
            simplexNoise4d(vec4(particle.xyz * uFlowFieldFrequency + 2.0, time))
        );
        
        // ...
    }

    // ...
}
lesson demo video preview

更进一步

这节课就到这里。

一如既往,随时可以更进一步。这里有一些建议: